Restructure Portal code to be inside Editor (#6857)

# Description of Changes
We don't have any strong reasons to keep the Portal as a separate Vite
app, and it needs access to so many things from the Editor that it no
longer makes sense to keep them separate. This PR moves the Portal code
to have direct access to the Editor code and gets rid of the shared
folder.
This commit is contained in:
James Brunton
2026-07-03 13:20:02 +00:00
committed by GitHub
parent 675afe9b71
commit 11ba3814e5
689 changed files with 4509 additions and 4783 deletions
-246
View File
@@ -1,246 +0,0 @@
/**
* Single-origin dev server for testing unified auth locally.
*
* The editor and portal store their session as a `stirling_jwt` token in
* localStorage, which the browser scopes per origin. On separate dev ports they
* can't share it; this server fronts both apps plus the backend on ONE origin
* so a token from one app is automatically seen by the other - mirroring the
* real same-origin production topology.
*
* Routing (single port):
* /api, /oauth2, /saml2, /v1/api-docs -> reverse-proxy to the backend
* /portal, /portal/* -> the portal app
* everything else -> the editor app (SPA)
*
* Each app is served either from a production build (static files) or from a
* running Vite dev server, chosen per app:
* - EDITOR_DEV_URL / PORTAL_DEV_URL set -> reverse-proxy to that dev server.
* - otherwise serve EDITOR_DIST / PORTAL_DIST as static files (SPA fallback).
*
* Vite HMR websockets connect straight to each dev server's own port, so live
* mode keeps hot reload without this proxy needing to multiplex HMR sockets.
*
* Config via env: PORT, BACKEND_URL, EDITOR_DEV_URL, PORTAL_DEV_URL,
* EDITOR_DIST, PORTAL_DIST.
*/
import http from "node:http";
import https from "node:https";
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { Duplex } from "node:stream";
const here = path.dirname(fileURLToPath(import.meta.url));
const FRONTEND = path.resolve(here, "..");
const PORT = Number(process.env.PORT || 3000);
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8080";
const EDITOR_DEV_URL = process.env.EDITOR_DEV_URL || "";
const PORTAL_DEV_URL = process.env.PORTAL_DEV_URL || "";
const EDITOR_DIST = path.resolve(
process.env.EDITOR_DIST || path.join(FRONTEND, "editor", "dist"),
);
const PORTAL_DIST = path.resolve(
process.env.PORTAL_DIST || path.join(FRONTEND, "dist-portal"),
);
const backend = new URL(BACKEND_URL);
const editorDev = EDITOR_DEV_URL ? new URL(EDITOR_DEV_URL) : null;
const portalDev = PORTAL_DEV_URL ? new URL(PORTAL_DEV_URL) : null;
// Paths owned by the backend (mirrors the editor/portal Vite dev proxies).
const API_PREFIXES = ["/api", "/oauth2", "/saml2", "/v1/api-docs"];
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".wasm": "application/wasm",
".map": "application/json",
".txt": "text/plain; charset=utf-8",
".toml": "text/plain; charset=utf-8",
};
function isApiPath(pathname: string): boolean {
return API_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(`${p}/`),
);
}
function clientFor(target: URL): typeof http | typeof https {
return target.protocol === "https:" ? https : http;
}
function proxyHttp(
req: http.IncomingMessage,
res: http.ServerResponse,
target: URL,
label: string,
): void {
const upstream = clientFor(target).request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port,
path: req.url,
method: req.method,
headers: { ...req.headers, host: target.host },
},
(upRes) => {
res.writeHead(upRes.statusCode || 502, upRes.headers);
upRes.pipe(res);
},
);
upstream.on("error", (err: Error) => {
res.writeHead(502, { "Content-Type": "text/plain" });
res.end(`${label} not reachable at ${target.origin}: ${err.message}`);
});
req.pipe(upstream);
}
async function serveStatic(
distDir: string,
urlPath: string,
res: http.ServerResponse,
): Promise<void> {
const pathname = decodeURIComponent((urlPath.split("?")[0] || "/").trim());
const hasExtension = path.extname(pathname) !== "";
// Routes (no file extension) and "/" fall back to index.html so the SPA
// router can take over; real assets resolve to their file.
const candidate = hasExtension
? path.join(distDir, pathname)
: path.join(distDir, "index.html");
const resolved = path.resolve(candidate);
// Path-traversal guard.
if (resolved !== distDir && !resolved.startsWith(distDir + path.sep)) {
res.writeHead(403, { "Content-Type": "text/plain" });
res.end("Forbidden");
return;
}
try {
const info = await stat(resolved);
if (!info.isFile()) throw new Error("not a file");
} catch {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end(
hasExtension
? "Not found"
: `index.html not found in ${distDir}. Did the build run? (task dev:portal:proxy builds first)`,
);
return;
}
const ext = path.extname(resolved).toLowerCase();
res.writeHead(200, {
"Content-Type": MIME[ext] || "application/octet-stream",
"Cache-Control": "no-store",
});
createReadStream(resolved).pipe(res);
}
const server = http.createServer((req, res) => {
const pathname = (req.url || "/").split("?")[0];
if (isApiPath(pathname)) {
proxyHttp(req, res, backend, "Backend");
return;
}
if (pathname === "/portal" || pathname.startsWith("/portal/")) {
if (portalDev) {
// Dev server is served under the /portal base, so keep the full path.
proxyHttp(req, res, portalDev, "Portal dev server");
} else {
const rest = (req.url || "").slice("/portal".length) || "/";
void serveStatic(PORTAL_DIST, rest, res);
}
return;
}
if (editorDev) {
proxyHttp(req, res, editorDev, "Editor dev server");
} else {
void serveStatic(EDITOR_DIST, req.url || "/", res);
}
});
// Forward websocket upgrades to the matching upstream. Backend streaming
// endpoints need /api; the dev servers' HMR sockets connect directly to their
// own ports, but routing them here too keeps things working if a browser sends
// them through the proxy.
function proxyUpgrade(
req: http.IncomingMessage,
socket: Duplex,
head: Buffer,
target: URL,
): void {
const upstream = clientFor(target).request({
protocol: target.protocol,
hostname: target.hostname,
port: target.port,
path: req.url,
method: req.method,
headers: { ...req.headers, host: target.host },
});
upstream.on("upgrade", (upRes, upSocket, upHead) => {
const headerLines = Object.entries(upRes.headers).map(
([k, v]) => `${k}: ${v as string}`,
);
socket.write(
`HTTP/1.1 101 Switching Protocols\r\n${headerLines.join("\r\n")}\r\n\r\n`,
);
if (upHead && upHead.length) upSocket.unshift(upHead);
if (head && head.length) upSocket.write(head);
upSocket.pipe(socket);
socket.pipe(upSocket);
upSocket.on("error", () => socket.destroy());
socket.on("error", () => upSocket.destroy());
});
upstream.on("error", () => socket.destroy());
upstream.end();
}
server.on("upgrade", (req, socket, head) => {
const pathname = (req.url || "/").split("?")[0];
if (isApiPath(pathname)) {
proxyUpgrade(req, socket, head, backend);
} else if (
portalDev &&
(pathname === "/portal" || pathname.startsWith("/portal/"))
) {
proxyUpgrade(req, socket, head, portalDev);
} else if (editorDev) {
proxyUpgrade(req, socket, head, editorDev);
} else {
socket.destroy();
}
});
server.listen(PORT, () => {
const editorSrc = editorDev ? `${editorDev.origin} (dev)` : EDITOR_DIST;
const portalSrc = portalDev ? `${portalDev.origin} (dev)` : PORTAL_DIST;
console.log("");
console.log(" Unified-auth single-origin server");
console.log(` ▶ open http://localhost:${PORT}/ (editor)`);
console.log(` ▶ open http://localhost:${PORT}/portal (portal)`);
console.log(` ▶ backend ${BACKEND_URL} (proxying /api, /oauth2, /saml2)`);
console.log(` ▶ editor ${editorSrc}`);
console.log(` ▶ portal ${portalSrc}`);
console.log(
" Log into one, open the other - the stirling_jwt token is shared (same origin).",
);
console.log("");
});
-15
View File
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["es2022"],
"module": "esnext",
"moduleResolution": "bundler",
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true
},
"include": ["./**/*.ts", "./**/*.mts"]
}