- Remove redirect_uri from Discord token exchange (Activities use proxy auth, not redirect flow) - Add Content-Security-Policy with frame-ancestors for Discord embedding (was only in vercel.json) - Wire up subscription create-checkout and manage routes in Express - Add Studio arm to ArmSwitcher with external link - Prevent SPA catch-all from serving HTML for missing static assets (fixes script.js Unexpected token error) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
378 lines
11 KiB
TypeScript
378 lines
11 KiB
TypeScript
import path from "path";
|
|
import { fileURLToPath } from "url";
|
|
import { readFileSync } from "fs";
|
|
import { createServer } from "./index";
|
|
import * as express from "express";
|
|
import { adminSupabase } from "./supabase";
|
|
|
|
const app = createServer();
|
|
const port = process.env.PORT || 5000;
|
|
const host = "0.0.0.0";
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const distPath = path.join(__dirname, "../spa");
|
|
|
|
// Serve static files
|
|
app.use(express.static(distPath));
|
|
|
|
// ── SSR Meta Injection ────────────────────────────────────────────────────────
|
|
|
|
const BASE_URL = "https://aethex.dev";
|
|
const DEFAULT_OG_IMAGE =
|
|
"https://docs.aethex.tech/~gitbook/image?url=https%3A%2F%2F1143808467-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-x-prod.appspot.com%2Fo%2Forganizations%252FDhUg3jal6kdpG645FzIl%252Fsites%252Fsite_HeOmR%252Flogo%252FqxDYz8Oj2SnwUTa8t3UB%252FAeThex%2520Origin%2520logo.png%3Falt%3Dmedia%26token%3D200e8ea2-0129-4cbe-b516-4a53f60c512b&width=1200&dpr=1&quality=100&sign=6c7576ce&sv=2";
|
|
|
|
type RouteMeta = {
|
|
title: string;
|
|
description: string;
|
|
image?: string;
|
|
type?: string;
|
|
};
|
|
|
|
/** Static route overrides — matched in order, first match wins. */
|
|
const STATIC_META: Array<{ pattern: RegExp; meta: RouteMeta }> = [
|
|
{
|
|
pattern: /^\/$/,
|
|
meta: {
|
|
title: "AeThex | Developer Platform for Builders, Creators & Innovation",
|
|
description:
|
|
"AeThex: an advanced development platform and community for builders. Collaborate on projects, learn, and ship innovation.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/projects\/?$/,
|
|
meta: {
|
|
title: "AeThex | Projects",
|
|
description:
|
|
"Explore open-source and community projects built on the AeThex platform.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/projects\/new/,
|
|
meta: {
|
|
title: "AeThex | New Project",
|
|
description: "Start a new project on AeThex.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/gameforge/,
|
|
meta: {
|
|
title: "AeThex | GameForge Studio",
|
|
description:
|
|
"GameForge — AeThex's game development studio for indie game creators. Build, manage, and ship games.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/ethos/,
|
|
meta: {
|
|
title: "AeThex | Ethos Guild",
|
|
description:
|
|
"Ethos Guild — collaborative music creation, licensing, and creative work on AeThex.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/dev-platform/,
|
|
meta: {
|
|
title: "AeThex | Developer Platform",
|
|
description:
|
|
"Build with AeThex APIs. Documentation, SDKs, examples, and developer tools for modern builders.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/feed/,
|
|
meta: {
|
|
title: "AeThex | Community Feed",
|
|
description: "What's happening in the AeThex builder community.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/login/,
|
|
meta: {
|
|
title: "AeThex | Sign In",
|
|
description:
|
|
"Sign in to your AeThex account to access projects, community, and more.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/register/,
|
|
meta: {
|
|
title: "AeThex | Create Account",
|
|
description:
|
|
"Join AeThex and start building, learning, and collaborating with the community.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/dashboard/,
|
|
meta: {
|
|
title: "AeThex | Dashboard",
|
|
description:
|
|
"Your personal AeThex dashboard — manage projects, track progress, and connect.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/passport\/me$/,
|
|
meta: {
|
|
title: "AeThex | My Passport",
|
|
description: "View your AeThex developer passport and profile.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/docs/,
|
|
meta: {
|
|
title: "AeThex | Documentation",
|
|
description: "Guides, API references, and developer resources for AeThex.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/pricing/,
|
|
meta: {
|
|
title: "AeThex | Pricing",
|
|
description:
|
|
"Simple, transparent pricing for AeThex — individuals, teams, and enterprises.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/about/,
|
|
meta: {
|
|
title: "AeThex | About",
|
|
description:
|
|
"Learn about AeThex — our mission, team, and the technology we build.",
|
|
},
|
|
},
|
|
{
|
|
pattern: /^\/blog/,
|
|
meta: {
|
|
title: "AeThex | Blog",
|
|
description:
|
|
"News, tutorials, and announcements from the AeThex team and community.",
|
|
},
|
|
},
|
|
];
|
|
|
|
// UUID pattern
|
|
const PROJECT_UUID_RE =
|
|
/^\/projects\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
// Passport: /passport/<username> (not "me", handled above)
|
|
const PASSPORT_USER_RE = /^\/passport\/([^/]+)$/;
|
|
|
|
function escHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """)
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">");
|
|
}
|
|
|
|
/**
|
|
* Replaces meta tag content values in the HTML template.
|
|
* Handles both inline and multi-line attribute formats (Vite preserves them).
|
|
*/
|
|
function injectMeta(
|
|
html: string,
|
|
meta: RouteMeta & { url: string }
|
|
): string {
|
|
const {
|
|
title,
|
|
description,
|
|
url,
|
|
image = DEFAULT_OG_IMAGE,
|
|
type = "website",
|
|
} = meta;
|
|
|
|
const t = escHtml(title);
|
|
const d = escHtml(description);
|
|
const u = escHtml(url);
|
|
const img = escHtml(image);
|
|
|
|
return (
|
|
html
|
|
// <title>
|
|
.replace(/<title>[^<]*<\/title>/, `<title>${t}</title>`)
|
|
// meta name="description"
|
|
.replace(
|
|
/(<meta\s+name="description"\s+content=")[^"]*"/,
|
|
`$1${d}"`
|
|
)
|
|
// og:title
|
|
.replace(
|
|
/(<meta\s+property="og:title"\s+content=")[^"]*"/,
|
|
`$1${t}"`
|
|
)
|
|
// og:description
|
|
.replace(
|
|
/(<meta\s+property="og:description"\s+content=")[^"]*"/,
|
|
`$1${d}"`
|
|
)
|
|
// og:url
|
|
.replace(
|
|
/(<meta\s+property="og:url"\s+content=")[^"]*"/,
|
|
`$1${u}"`
|
|
)
|
|
// og:type
|
|
.replace(
|
|
/(<meta\s+property="og:type"\s+content=")[^"]*"/,
|
|
`$1${type}"`
|
|
)
|
|
// og:image (first occurrence — the main image tag, not og:image:width/height)
|
|
.replace(
|
|
/(<meta\s+property="og:image"\s+content=")[^"]*"/,
|
|
`$1${img}"`
|
|
)
|
|
// twitter:title
|
|
.replace(
|
|
/(<meta\s+name="twitter:title"\s+content=")[^"]*"/,
|
|
`$1${t}"`
|
|
)
|
|
// twitter:description
|
|
.replace(
|
|
/(<meta\s+name="twitter:description"\s+content=")[^"]*"/,
|
|
`$1${d}"`
|
|
)
|
|
// twitter:image
|
|
.replace(
|
|
/(<meta\s+name="twitter:image"\s+content=")[^"]*"/,
|
|
`$1${img}"`
|
|
)
|
|
// canonical link
|
|
.replace(
|
|
/(<link\s+rel="canonical"\s+href=")[^"]*"/,
|
|
`$1${u}"`
|
|
)
|
|
);
|
|
}
|
|
|
|
/** Resolve per-route meta, fetching from DB for dynamic routes. */
|
|
async function resolveRouteMeta(
|
|
pathname: string
|
|
): Promise<RouteMeta & { url: string }> {
|
|
const url = `${BASE_URL}${pathname}`;
|
|
|
|
// Dynamic: project detail page
|
|
const projectMatch = PROJECT_UUID_RE.exec(pathname);
|
|
if (projectMatch && adminSupabase) {
|
|
const projectId = projectMatch[1];
|
|
try {
|
|
const { data: project } = await (adminSupabase as any)
|
|
.from("projects")
|
|
.select("title, description, image_url, status")
|
|
.eq("id", projectId)
|
|
.maybeSingle();
|
|
if (project) {
|
|
const desc = project.description
|
|
? String(project.description).slice(0, 160)
|
|
: `View the ${project.title} project on AeThex.`;
|
|
return {
|
|
title: `${project.title} — AeThex Project`,
|
|
description: desc,
|
|
image: project.image_url || DEFAULT_OG_IMAGE,
|
|
type: "article",
|
|
url,
|
|
};
|
|
}
|
|
} catch {
|
|
// fall through to default
|
|
}
|
|
}
|
|
|
|
// Dynamic: public passport / profile page
|
|
const passportMatch = PASSPORT_USER_RE.exec(pathname);
|
|
if (passportMatch && adminSupabase) {
|
|
const username = passportMatch[1];
|
|
try {
|
|
const { data: profile } = await adminSupabase
|
|
.from("user_profiles")
|
|
.select("username, full_name, avatar_url, bio")
|
|
.eq("username", username)
|
|
.maybeSingle();
|
|
if (profile) {
|
|
const displayName =
|
|
(profile as any).full_name || profile.username || username;
|
|
const bio = (profile as any).bio;
|
|
const desc = bio
|
|
? String(bio).slice(0, 160)
|
|
: `View ${displayName}'s developer passport and projects on AeThex.`;
|
|
return {
|
|
title: `${displayName} — AeThex Passport`,
|
|
description: desc,
|
|
url,
|
|
};
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
|
|
// Static route map
|
|
for (const { pattern, meta } of STATIC_META) {
|
|
if (pattern.test(pathname)) {
|
|
return { ...meta, url };
|
|
}
|
|
}
|
|
|
|
// Default fallback
|
|
return {
|
|
title: "AeThex | Developer Platform for Builders, Creators & Innovation",
|
|
description:
|
|
"AeThex: an advanced development platform and community for builders. Collaborate on projects, learn, and ship innovation.",
|
|
url,
|
|
};
|
|
}
|
|
|
|
// Cache the HTML template (reset on read error so a rebuild is picked up on restart)
|
|
let htmlTemplate: string | null = null;
|
|
|
|
function getTemplate(): string {
|
|
if (!htmlTemplate) {
|
|
htmlTemplate = readFileSync(path.join(distPath, "index.html"), "utf-8");
|
|
}
|
|
return htmlTemplate;
|
|
}
|
|
|
|
// ── Route Handler ─────────────────────────────────────────────────────────────
|
|
|
|
app.get("*", async (req, res) => {
|
|
if (req.path.startsWith("/api/") || req.path.startsWith("/health")) {
|
|
return res.status(404).json({ error: "API endpoint not found" });
|
|
}
|
|
|
|
// Don't serve the SPA shell for missing static-asset requests — they should 404
|
|
// cleanly rather than returning HTML (which causes "Unexpected token '<'" JS errors).
|
|
if (/\.(js|mjs|cjs|css|map|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|webp|avif)$/i.test(req.path)) {
|
|
return res.status(404).send("Not found");
|
|
}
|
|
|
|
try {
|
|
const template = getTemplate();
|
|
const meta = await resolveRouteMeta(req.path);
|
|
const html = injectMeta(template, meta);
|
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
// Crawlers should not cache — browsers can
|
|
res.setHeader(
|
|
"Cache-Control",
|
|
"public, max-age=60, stale-while-revalidate=300"
|
|
);
|
|
res.send(html);
|
|
} catch (err) {
|
|
console.error("[SSR Meta] Error:", err);
|
|
// Fallback: send unmodified file
|
|
res.sendFile(path.join(distPath, "index.html"));
|
|
}
|
|
});
|
|
|
|
// ── Server ────────────────────────────────────────────────────────────────────
|
|
|
|
app.listen(Number(port), host, () => {
|
|
console.log(`🚀 AeThex server running on ${host}:${port}`);
|
|
console.log(`📱 Frontend: http://${host}:${port}`);
|
|
console.log(`🔧 API: http://${host}:${port}/api`);
|
|
});
|
|
|
|
process.on("SIGTERM", () => {
|
|
console.log("🛑 Received SIGTERM, shutting down gracefully");
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on("SIGINT", () => {
|
|
console.log("🛑 Received SIGINT, shutting down gracefully");
|
|
process.exit(0);
|
|
});
|